Skip to content

This change versions the Lens API by serving every public route under /v1 while keeping the existing unversioned paths as deprecated, behavior-identical aliases. Versioning is handled centrally with a Fastify rewriteUrl hook instead of mounting routes twice, so both forms resolve to the same handler and cannot drift. - #427

Merged
Miracle656 merged 9 commits into
Divinity-Labs:mainfrom
DevScoopee:There-is-no-version-in-the-URL
Oct 25, 2026

Conversation

@DevScoopee

Copy link
Copy Markdown
Contributor

The routes previously had no version segment, which made the in-flight response-shape changes impossible to ship without breaking existing callers. To fix this, the /v1 segment is stripped before routing so /v1/price/... and /price/... hit one identical route definition, and requests using the deprecated alias get Deprecation: true and a Link: </v1/...>; rel="successor-version" header pointing at the versioned path. I added path helpers to src/openapi/coverage.ts, wired the rewrite and warning hook in src/app.ts, moved all public paths in openapi.yaml under /v1 (regenerating openapi.json), and extended tests/openapi.test.ts to assert that every documented versioned path maps to a registered alias, that the alias always exists, and that both forms answer identically. I also wrote the warning contract and retirement window in docs/api-versioning.md, updated the README endpoint table, and added a minor changeset. No callers break because the unversioned paths still answer identically

Closes #264

@drips-wave

drips-wave Bot commented Oct 17, 2026

Copy link
Copy Markdown

@DevScoopee Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

DevScoopee and others added 5 commits October 17, 2026 07:26
… /v1 while keeping the existing unversioned paths as deprecated, behavior-identical aliases. Versioning is handled centrally with a Fastify rewriteUrl hook instead of mounting routes twice, so both forms resolve to the same handler and cannot drift.

The routes previously had no version segment, which made the in-flight response-shape changes impossible to ship without breaking existing callers. To fix this, the /v1 segment is stripped before routing so /v1/price/... and /price/... hit one identical route definition, and requests using the deprecated alias get Deprecation: true and a Link: </v1/...>; rel="successor-version" header pointing at the versioned path. I added path helpers to src/openapi/coverage.ts, wired the rewrite and warning hook in src/app.ts, moved all public paths in openapi.yaml under /v1 (regenerating openapi.json), and extended tests/openapi.test.ts to assert that every documented versioned path maps to a registered alias, that the alias always exists, and that both forms answer identically. I also wrote the warning contract and retirement window in docs/api-versioning.md, updated the README endpoint table, and added a minor changeset. No callers break because the unversioned paths still answer identically

Closes Divinity-Labs#264
Miracle656 added a commit that referenced this pull request Oct 22, 2026
  tsc --noEmit   clean
  vitest         91 files, 1052 passing (up from 86 / 962 on main)

  #251  read and scale token decimals from the chain
  #307  standardise empty-result vs not-found status codes
  #318  validate the live service against the published OpenAPI
  #324  content-derived strong ETag and honour conditional requests
  #326  cover vwap and the aggregator
  #388  persist permanent webhook delivery failures

BREAKING, and deliberate: #307 changes routes that returned 404 for "no data"
to 200 with an explicit reason. The rule is now 404 = the resource does not
exist (pair not watched, key not found), 200 + empty = the query matched
nothing. Our dropped-test guard surfaced it as

  - src/__tests__/basket.test.ts :: returns 404 when an asset has no price data

which turned out to be renamed rather than deleted, to
"returns 200 with error and reason when an asset has no price data". That is
the guard working as intended: it made a behaviour change a decision instead
of an accident. Any client branching on 404 for empty results needs updating;
#427 (API versioning) is open and is where this belongs long-term.

Three fixes applied on merge:

  - #388 and #318 each added a console.* call to non-test source, which the
    repo's own logging-hygiene test forbids. Both now use createLogger, which
    is what delivery.ts was already doing two lines above the offending call.
  - #326's test asserted CONFIDENCE_HIGH/MEDIUM_THRESHOLD_SECONDS, which do
    not exist. The constants are real and the values match, but the shipped
    names are HIGH_/MEDIUM_CONFIDENCE_MAX_AGE_SECONDS, from #545. Updated the
    test to the shipped names rather than renaming source to fit a stale test.
  - npx prisma generate, for #388's WebhookDeliveryFailure model.

NOT merged: #384 (periodic WebSocket ping) does not compile -- it reads
config.ws and never adds it to src/config.ts, and it drops a fanout test.
#254 conflicts.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The branch does not build. src/app.ts calls six functions it no longer imports, so the app cannot boot and the test suite that would have caught the rest never runs.

src/app.ts(145,7):  error TS2304: Cannot find name 'rate_limit_rejections_total'.
src/app.ts(174,3):  error TS2304: Cannot find name 'registerGlobalRateLimit'.
src/app.ts(174,32): error TS2304: Cannot find name 'parseGlobalRateLimit'.
src/app.ts(204,22): error TS2304: Cannot find name 'registerCachePolicy'.
src/app.ts(224,9):  error TS2552: Cannot find name 'registerFiatRoutes'.
src/app.ts(226,9):  error TS2552: Cannot find name 'registerHealthRoutes'.

This came from the Merge branch 'main' into There-is-no-version-in-the-URL commit (9beacda), which kept each call and dropped the matching import. main has 42 imports in that file; this branch has 38. For example main carries

import { registerGlobalRateLimit, parseGlobalRateLimit } from './middleware/globalRateLimit'

at src/app.ts:40, and the call survives here at line 174 with nothing importing it.

What that hides

tests/openapi.test.ts boots the app in beforeAll, so it throws ReferenceError: registerGlobalRateLimit is not defined and all 11 tests report as skipped rather than failed — including the new coverage guard this PR adds. The suite that exists to prove the spec matches the routes is the one that cannot run.

Across the whole suite: 7 files failing, 10 tests failing, 56 skipped.

What it would have caught

openapi.yaml goes from 35 documented paths to 25, and ten endpoints disappear from the spec entirely — not re-added under /v1:

book/{assetA}/{assetB}   correlation   impact/{assetA}/{assetB}
live                     movers        price/{asset}/fiat
ready                    snapshot      usage
venues/registry

Some of those are a direct consequence of the same botched merge — registerHealthRoutes and registerFiatRoutes are two of the six dropped imports, so /live, /ready and /price/{asset}/fiat are not merely undocumented, their registration is gone. /live and /ready are the health probes.

The others (/movers, /correlation, /book, /impact, /snapshot, /usage, /venues/registry) need checking individually: either they want /v1 entries in the spec, or they belong in INTERNAL_ROUTES with a reason.

The design is right

I want to be clear that the approach is good and worth finishing. A central rewriteUrl hook instead of mounting every route twice is the correct call — two registrations are two things that can drift. src/openapi/coverage.ts is better than what it replaces: every exclusion carries a reason, prefixes are kept separate from exact matches so a broad prefix cannot quietly excuse a public route, and UNDOCUMENTABLE_OPERATIONS distinguishes "this path is private" from "this one method cannot be expressed in OpenAPI 3.0". The note about QUERY /graphql needing 3.2 is exactly the kind of thing that should be written down.

What to do

Rebase on current main rather than merging it again — the branch is 29 commits behind now, and a rebase makes a dropped import show up as a conflict instead of a silent deletion. Then npx tsc --noEmit and npx vitest run both clean locally before re-requesting: tsc catches all six of these in one command.

One thing to decide while you are there: the spec documents only /v1/... paths, so the unversioned aliases this PR deliberately keeps are invisible to anyone reading the published document. If they are supported, they should probably appear as deprecated entries; if the spec is meant to describe only the versioned surface, say so in docs/api-versioning.md so the omission reads as a decision.

@Miracle656 Miracle656 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The versioning design is right and I want it. It is blocked on one thing: the spec in this branch is a regeneration of an older main, so merging it would undocument thirteen live routes.

The approach is the correct one

// Rather than registering each route twice (which would double the surface and,
// more importantly, let the two copies drift in middleware behaviour — x402
// gating, auth and rate limits all key off the *routed* path), the version
// segment is stripped before Fastify routes the request.

That is exactly the reasoning I would want. Mounting every route twice is the obvious implementation and the wrong one: /v1/admin/keys and /admin/keys would be two route objects, and the moment one gains a config the other does not, they answer differently — which on the admin and x402 paths means an auth or payment gate that applies to one spelling and not the other. A rewriteUrl that collapses both onto a single registration cannot drift, because there is only one thing to drift.

I checked the two guards that landed today against it, and both hold: rewriteUrl runs before routing, so the CORS classifier (#316) and the admin auth hook (#574) both see the bare path — /v1/admin/keys is classified admin and goes through registerAdminAuth exactly as /admin/keys does. No bypass.

Keeping the unversioned form as a deprecated alias with warning headers, and identifying the deprecated caller from req.raw.originalUrl after the rewrite, is the right way round too.

Blocking: the spec would lose thirteen routes

openapi.yaml in this branch has 25 paths. main has 38. These thirteen exist on main and appear in this branch under no form, versioned or bare:

/book/{assetA}/{assetB}      /live                 /rates/{asset}
/correlation                 /movers               /ready
/discovery/search            /price/{asset}/fiat   /snapshot
/impact/{assetA}/{assetB}    /tvl                  /usage
/venues/registry

I merged main into the branch locally to check whether git would restore them. It does not — only README.md and src/app.ts conflict, and openapi.yaml merges cleanly to 25 paths, because relative to the merge base the branch genuinely deletes them. Git has no way to tell "regenerated from an older spec" from "deliberately removed".

The blast radius is wider than the spec file, because three other things are generated from it:

  • openapi.json (CI byte-compares it),
  • clients/typescript/src/schema.d.ts — the published client would lose those thirteen endpoints,
  • the README endpoint table, which tests/readme.test.ts derives from openapi.yaml.

So a merge would quietly remove /tvl, /movers, /correlation and the rest from the documentation, the typed client and the README at once, while the routes keep serving traffic.

What to do

Rebase onto main rather than merging it. A rebase replays your versioning change on top of the current spec, so the thirteen routes are simply present and get /v1 forms like everything else. Merging asks git to reconcile two whole-file rewrites, which is how the deletions survive.

After the rebase:

  1. Re-apply the /v1 prefix across all 38 paths, not the 25 that existed when you branched.
  2. npm run openapi:gen — do not hand-edit openapi.json; it is byte-compared in CI.
  3. npx --yes openapi-typescript@7.13.0 openapi.yaml -o clients/typescript/src/schema.d.ts (npm run client:gen needs the binary on PATH).
  4. npm run readme:gen — the README endpoint table comes from the spec now, and its test will fail otherwise.
  5. npx tsc --noEmit, then npm test.

Your addition to src/openapi/coverage.ts should make the first point self-checking — if it compares registered routes against documented paths, a missing /tvl ought to fail the suite rather than wait for a reviewer to diff two files. Worth confirming it fires on this exact case, because that guard is the thing that stops this recurring.

One question on the deprecation contract while you are in there: docs/api-versioning.md describes the warning headers, but the window itself matters more. Installed Veil APKs call these routes with no /v1, and they cannot be rebuilt on demand — so the unversioned alias has to outlive the install base, not a calendar date. Worth saying that explicitly in the doc rather than naming a removal date someone might act on.

Ping me when it is rebased. The hard part is already done and correct; this is the spec catching up.

@Miracle656
Miracle656 merged commit eca568f into Divinity-Labs:main Oct 25, 2026
1 of 3 checks passed
@Miracle656

Copy link
Copy Markdown
Collaborator

Merged into main (97c50bf). The versioning design is kept exactly as you built it — routes mounted once at the bare path, rewriteUrl resolving /v1/... onto them, the alias answering identically with Deprecation/Link. src/api/versioning.ts and src/openapi/coverage.ts are good work, and keying the middleware off the routed path to stop the two copies drifting is the right call. Four things had to be put back first.

1. The spec dropped 17 routes. openapi.yaml is hand-maintained here — only openapi.json is generated from it — so the committed file is the contract. It documented 26 paths against main's 42, and these were documented nowhere, neither under /v1 nor bare:

/book/{assetA}/{assetB}, /correlation, /discovery/search, /dispersion, /dispersion/{assetA}/{assetB}, /impact/{assetA}/{assetB}, /live, /movers, /price/{asset}/fiat, /rates, /rates/{asset}, /ready, /snapshot, /tvl, /usage, /venues, /venues/registry

They would have disappeared from the published spec and from the generated TypeScript client. /rates was also left unversioned, which your own new coverage test forbids. I took main's spec and moved every path key to its /v1 form, keeping your versioning paragraph in info.description. That is the mechanical transform the change actually implies — 42 paths in, 42 out.

2. src/app.ts reverted shipped work. This is the one to be careful about. It replaced the structured logger and genReqId with logger: { level: 'warn' }, dropped the setErrorHandler that routes 5xx through sendInternalError, dropped registerETag, and replaced the per-route CORS delegator from #268/#316 with origin: true — allow-all, on a surface that has credentialed and admin routes. The route-registration imports were deleted while the calls remained, which is what CI's typecheck was failing on. app.ts is now main's with only your three versioning touches reapplied.

exposedHeaders: ['Deprecation', 'Link'] went into createCorsDelegator rather than onto one register call, so every policy branch exposes them. Worth knowing: without it, a browser client cannot read your deprecation warning at all — custom response headers are hidden cross-origin unless exposed.

3. README lost its <!-- ENDPOINTS:END --> marker, so the generated-section contract broke and readme.test.ts could not find the block. Restored from main and regenerated — 43 versioned rows now.

4. Three test files assumed bare paths. rest-route now resolves the documented URL through rewriteVersionedApiUrl instead of looking it up in the route table, where /v1 never appears by design — that checks the rewrite as well as the route. liveSpecValidation's spec keys are versioned (leaving the e.path === '/price' JSON-pointer assertions alone — those are fields in the response body, not URLs). And resolveTestRequest normalises with toBarePath, because its query-parameter and body tables are keyed on the bare route, so /basket, /screener, /graphql, /verify, /settle and /webhooks had silently stopped matching.

I also restored isUndocumentableOperation in the method-coverage test: the branch removed the only call site but kept the allow-list, which would have reported QUERY /graphql as undocumented.

On the "No test case silently removed" check — that was a false alarm. You renamed documents only paths that are actually registered to documents only versioned paths that map back to a registered route, and the script compares names. The coverage is retained and stronger. The label is the right resolution there, not a code change.

tsc clean, 1,690 tests pass, generated artifacts in sync.

One general note for next time: openapi.yaml, README.md and clients/typescript/src/schema.d.ts are all byte-compared in CI. Run npm run openapi:gen, npm run readme:gen and npm run client:gen before pushing and commit what they write, rather than editing them by hand — every one of the four problems above was a hand edit to a file that has a generator or a guard.

https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

There is no version in the URL

2 participants